/** * GET /api/v1/issues/:id — Single issue detail for AI agents. * * Returns one issue with full description, threaded comments, * claim info, and enriched blockers/dependents (with title + status). * No auth required. */ import { loadBeadsData } from "@/lib/parse-beads"; import { discoverBeadsDir } from "@/lib/discover"; import { fetchBeadsComments, getClaimedNodes } from "@/lib/comments"; import type { BeadsComment, ClaimInfo } from "@/lib/comments"; import type { GraphNode } from "@/lib/types"; import { jsonResponse, errorResponse, OPTIONS } from "@/lib/api-helpers"; import { readFileSync } from "fs"; import { join } from "path"; export const dynamic = "force-dynamic"; export { OPTIONS }; let heartbeadsVersion = "0.0.0"; try { const pkg = JSON.parse( readFileSync(join(process.cwd(), "package.json"), "utf-8") ); heartbeadsVersion = pkg.version || heartbeadsVersion; } catch { // ignore } function serializeComment(c: BeadsComment): Record { return { author: { handle: c.handle, did: c.did, ...(c.displayName ? { displayName: c.displayName } : {}), }, text: c.text, createdAt: c.createdAt, likes: c.likes.length, replies: c.replies.map(serializeComment), }; } export async function GET( request: Request, { params }: { params: Promise<{ id: string }> } ) { try { const discovery = discoverBeadsDir(); const { id: issueId } = await params; // Load beads data + comments in parallel const [beadsData, commentsResult] = await Promise.all([ Promise.resolve(loadBeadsData(discovery.beadsDir)), fetchBeadsComments().catch((err) => { console.error("[api/v1/issues] Failed to fetch comments:", err); return null; }), ]); // Find the issue const nodeMap = new Map( beadsData.graphData.nodes.map((n) => [n.id, n]) ); const node = nodeMap.get(issueId); if (!node) { return errorResponse( "Issue not found", 404, `No issue with id "${issueId}". Use GET /api/v1/graph to list all issues.` ); } // Build enriched blockers/dependents with title + status // dependentIds = issues that block this one (upstream) // blockerIds = issues this one blocks (downstream) const enrichedBlockers = node.dependentIds .map((id) => { const n = nodeMap.get(id); return n ? { id: n.id, title: n.title, status: n.status } : { id, title: null, status: null }; }); const enrichedDependents = node.blockerIds .map((id) => { const n = nodeMap.get(id); return n ? { id: n.id, title: n.title, status: n.status } : { id, title: null, status: null }; }); // Comments and claims const nodeComments = commentsResult?.commentsByNode.get(issueId) || []; const claims = commentsResult ? getClaimedNodes(commentsResult.allComments) : new Map(); const claim = claims.get(issueId) || null; const warnings: string[] = []; if (!commentsResult) { warnings.push("Failed to fetch comments from indexer"); } return jsonResponse({ issue: { id: node.id, title: node.title, description: node.description || null, status: node.status, priority: node.priority, issue_type: node.issueType, owner: node.owner || null, assignee: node.assignee || null, labels: node.labels, created_at: node.createdAt, updated_at: node.updatedAt, closed_at: node.closedAt || null, close_reason: node.closeReason || null, prefix: node.prefix, blockers: enrichedBlockers, dependents: enrichedDependents, comments: nodeComments.map(serializeComment), claimed_by: claim ? { handle: claim.handle, did: claim.did, ...(claim.displayName ? { displayName: claim.displayName } : {}), claimed_at: claim.claimedAt, } : null, }, _meta: { generated_at: new Date().toISOString(), api_version: "v1" as const, heartbeads_version: heartbeadsVersion, ...(warnings.length ? { warnings } : {}), }, }); } catch (error: unknown) { const message = error instanceof Error ? error.message : "Unknown error"; if (message.includes("No .beads/ directory found")) { return errorResponse( "No .beads directory found", 404, "Run bd init in your project first, or set BEADS_DIR." ); } console.error("[api/v1/issues] Error:", error); return errorResponse("Internal server error", 500); } }